/* Copyright (c) 2008 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.gdata.client.http;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.net.CookieHandler;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.logging.Logger;

import com.google.appengine.api.urlfetch.URLFetchService;
import com.google.gdata.client.GDataProtocol;
import com.google.gdata.client.GoogleService;
import com.google.gdata.client.GoogleService.SessionExpiredException;
import com.google.gdata.client.Service.GDataRequest;
import com.google.gdata.util.AuthenticationException;
import com.google.gdata.util.ContentType;
import com.google.gdata.util.RedirectRequiredException;
import com.google.gdata.util.ServiceException;
import com.google.gdata.util.Version;

/**
 * The GoogleGDataRequest class provides a basic implementation of an interface
 * to connect with a Google-GData server.
 * 
 * 
 */
public class GoogleGDataRequest extends HttpGDataRequest {

	private static final Logger logger = Logger
			.getLogger(GoogleGDataRequest.class.getName());

	/**
	 * If set, this System property will globally disable interception and
	 * handling of cookies for all GData services.
	 */
	public static final String DISABLE_COOKIE_HANDLER_PROPERTY = "com.google.gdata.DisableCookieHandler";

	/*
	 * Disables cookie handling when run in AppEngine. This is a no-op if run
	 * outside of AppEngine.
	 */
	static {
		try {
			Class apiProxyClass = Class
					.forName("com.google.apphosting.api.ApiProxy");
			if (apiProxyClass.getMethod("getCurrentEnvironment").invoke(null) != null) {
				System.setProperty(DISABLE_COOKIE_HANDLER_PROPERTY, "true");
			}
		} catch (ClassNotFoundException e) {
		} catch (IllegalAccessException e) {
		} catch (InvocationTargetException e) {
		} catch (NoSuchMethodException e) {
		}
	}

	/**
	 * The GoogleGDataRequest.Factory class is a factory class for constructing
	 * new GoogleGDataRequest instances.
	 */
	public static class Factory extends HttpGDataRequest.Factory {
		@Override
		protected GDataRequest createRequest(RequestType type, URL requestUrl,
				ContentType contentType) throws IOException, ServiceException {
			return new GoogleGDataRequest(type, requestUrl, contentType,
					authToken, headerMap, privateHeaderMap, urlFetchService);
		}
	}

	/**
	 * Google cookie.
	 */
	public static class GoogleCookie {

		// Cookie state. All fields have public accessors, except for cookie
		// values which are restricted to package-level access for security.
		private String domain;

		public String getDomain() {
			return domain;
		}

		private String path;

		public String getPath() {
			return path;
		}

		private String name;

		public String getName() {
			return name;
		}

		private String value;

		String getValue() {
			return value;
		}

		private Date expires;

		public Date getExpires() {
			return (expires != null) ? (Date) expires.clone() : null;
		}

		/**
		 * Constructs a new GoogleCookie instance.
		 * 
		 * @param uri
		 *            the original request URI that returned Set-Cookie header
		 *            in the response
		 * @param cookieHeader
		 *            the value of the Set-Cookie header.
		 */
		public GoogleCookie(URI uri, String cookieHeader) {

			// Set default values
			String attributes[] = cookieHeader.split(";");
			String nameValue = attributes[0].trim();
			int equals = nameValue.indexOf('=');
			if (equals < 0) {
				throw new IllegalArgumentException(
						"Cookie is not a name/value pair");
			}
			this.name = nameValue.substring(0, equals);
			this.value = nameValue.substring(equals + 1);
			this.path = "/";
			this.domain = uri.getHost();

			// Process optional cookie attributes
			for (int i = 1; i < attributes.length; i++) {
				nameValue = attributes[i].trim();
				equals = nameValue.indexOf('=');
				if (equals == -1) {
					continue;
				}
				String name = nameValue.substring(0, equals);
				String value = nameValue.substring(equals + 1);
				if (name.equalsIgnoreCase("domain")) {
					if (uri.getPort() > 0) {
						// ignore port
						int colon = value.lastIndexOf(':');
						if (colon > 0) {
							value = value.substring(0, colon);
						}
					}
					String uriDomain = uri.getHost();
					if (uriDomain.equals(value)) {
						this.domain = value;
					} else {
						if (!matchDomain(uriDomain, value)) {
							throw new IllegalArgumentException(
									"Trying to set foreign cookie");
						}
					}
					this.domain = value;
				} else if (name.equalsIgnoreCase("path")) {
					this.path = value;
				} else if (name.equalsIgnoreCase("expires")) {
					try {
						this.expires = new SimpleDateFormat(
								"E, dd-MMM-yyyy k:m:s 'GMT'", Locale.US)
								.parse(value);
					} catch (java.text.ParseException e) {
						try {
							this.expires = new SimpleDateFormat(
									"E, dd MMM yyyy k:m:s 'GMT'", Locale.US)
									.parse(value);
						} catch (java.text.ParseException e2) {
							throw new IllegalArgumentException(
									"Bad date format in header: " + value);
						}
					}
				}
			}
		}

		/**
		 * Returns true if the full domain's final segments match the tail
		 * domain.
		 */
		private boolean matchDomain(String testDomain, String tailDomain) {

			// Simple check
			if (!testDomain.endsWith(tailDomain)) {
				return false;
			}

			// Exact match
			if (testDomain.length() == tailDomain.length()) {
				return true;
			}

			// Verify that a segment match happened, not a partial match
			if (tailDomain.charAt(0) == '.') {
				return true;
			}
			return testDomain.charAt(testDomain.length() - tailDomain.length()
					- 1) == '.';
		}

		/**
		 * Returns {@code true} if the cookie has expired.
		 */
		public boolean hasExpired() {
			if (expires == null) {
				return false;
			}
			Date now = new Date();
			return now.after(expires);
		}

		/**
		 * Returns {@code true} if the cookie hasn't expired, the URI domain
		 * matches, and the URI path starts with the cookie path.
		 * 
		 * @param uri
		 *            URI to check against
		 * @return true if match, false otherwise
		 */
		public boolean matches(URI uri) {

			if (hasExpired()) {
				return false;
			}

			String uriDomain = uri.getHost();
			if (!matchDomain(uriDomain, domain)) {
				return false;
			}

			String path = uri.getPath();
			if (path == null) {
				path = "/";
			}

			return path.startsWith(this.path);
		}

		/**
		 * Returns the actual name/value pair that should be sent in a Cookie
		 * request header.
		 */
		String getHeaderValue() {
			StringBuilder result = new StringBuilder(name);
			result.append("=");
			result.append(value);
			return result.toString();
		}

		/**
		 * Returns {@code true} if the target object is a GoogleCookie that has
		 * the same name as this cookie and that matches the same target domain
		 * and path as this cookie. Cookie expiration and value <b>are not</b>
		 * taken into account when considering equivalence.
		 */
		@Override
		public boolean equals(Object o) {
			if (o == null || !(o instanceof GoogleCookie)) {
				return false;
			}
			GoogleCookie cookie = (GoogleCookie) o;
			if (!name.equals(cookie.name) || !domain.equals(cookie.domain)) {
				return false;
			}
			if (path == null) {
				if (cookie.path != null) {
					return false;
				}
				return true;
			}
			return path.equals(cookie.path);
		}

		@Override
		public int hashCode() {
			int result = 17;
			result = 37 * result + name.hashCode();
			result = 37 * result + domain.hashCode();
			result = 37 * result + (path != null ? path.hashCode() : 0);
			return result;
		}

		@Override
		public String toString() {
			StringBuilder buf = new StringBuilder("GoogleCookie(");
			buf.append(domain);
			buf.append(path);
			buf.append("[");
			buf.append(name);
			buf.append("]");
			buf.append(")");
			return buf.toString();
		}
	}

	/**
	 * Implements a scoped cookie handling mechanism for GData services. This
	 * handler is a singleton class that is registered to globally listen and
	 * set cookies using {@link CookieHandler#setDefault(CookieHandler)}. It
	 * will only process HTTP headers and responses associated with GData
	 * services, and will delegate the processing of any other cookie headers to
	 * the previously registered {@link CookieHandler} (if any). When a
	 * Set-Cookie response header is found, it will save any associated cookie
	 * in the cookie cache associated with the {@link GoogleService} issuing the
	 * request. Similarly, when a {@link GoogleService} issues a request, it
	 * will check its cookie cache and add any necessary Cookie header.
	 */
	private static class GoogleCookieHandler extends CookieHandler {

		private CookieHandler nextHandler;

		// This is a singleton, only constructed once at class load time.
		private GoogleCookieHandler() {

			// Install the global GoogleCookieHandler instance, chaining to any
			// existing CookieHandler
			if (!Boolean.getBoolean(DISABLE_COOKIE_HANDLER_PROPERTY)) {
				logger.fine("Installing GoogleCookieHandler");
				nextHandler = CookieHandler.getDefault();
				CookieHandler.setDefault(this);
			}
		}

		@Override
		public Map<String, List<String>> get(URI uri,
				Map<String, List<String>> requestHeaders) throws IOException {

			Map<String, List<String>> cookieHeaders = new HashMap<String, List<String>>();

			// Only service requests initiated by GData services with cookie
			// handling enabled.
			GoogleService service = activeService.get();
			if (service != null && service.handlesCookies()) {

				// Get the list of matching cookies and accumulate a buffer
				// containing the cookie name/value pairs.
				Set<GoogleCookie> cookies = service.getCookies();
				StringBuilder cookieBuf = new StringBuilder();
				for (GoogleCookie cookie : cookies) {
					if (cookie.matches(uri)) {
						if (cookieBuf.length() > 0) {
							cookieBuf.append("; ");
						}
						cookieBuf.append(cookie.getHeaderValue());
						logger.fine("Setting cookie: " + cookie);
					}
				}

				// If any matching cookies were found, update the request
				// headers.
				// Note: it's assumed here that nothing else is setting the
				// Cookie
				// header, which seems reasonable; otherwise we'd have to parse
				// the
				// existing value and add/merge managed cookies.
				if (cookieBuf.length() != 0) {
					cookieHeaders.put("Cookie", Collections
							.singletonList(cookieBuf.toString()));
				}
			} else {
				if (nextHandler != null) {
					return nextHandler.get(uri, requestHeaders);
				}
			}

			return Collections.unmodifiableMap(cookieHeaders);
		}

		@Override
		public void put(URI uri, Map<String, List<String>> responseHeaders)
				throws IOException {

			// Only service requests initiated by GData services with cookie
			// handling enabled.
			GoogleService service = activeService.get();
			if (service != null && service.handlesCookies()) {
				List<String> setCookieList = responseHeaders.get("Set-Cookie");
				if (setCookieList != null && setCookieList.size() > 0) {
					for (String cookieValue : setCookieList) {
						GoogleCookie cookie = new GoogleCookie(uri, cookieValue);
						service.addCookie(cookie);
						logger.fine("Adding cookie:" + cookie);
					}
				}
			} else {
				if (nextHandler != null) {
					nextHandler.get(uri, responseHeaders);
				}
			}
		}
	}

	/**
	 * Holds the GoogleService that is executing requests for the current
	 * execution thread.
	 */
	private static final ThreadLocal<GoogleService> activeService = new ThreadLocal<GoogleService>();

	/**
	 * The global CookieHandler instance for GData services.
	 */
	@SuppressWarnings("unused")
	// instance init installs global hooks.
	private static final GoogleCookieHandler googleCookieHandler;

	static {
		if (!Boolean.getBoolean(DISABLE_COOKIE_HANDLER_PROPERTY)) {
			googleCookieHandler = new GoogleCookieHandler();
		} else {
			googleCookieHandler = null;
		}
	}

	/**
	 * Constructs a new GoogleGDataRequest instance of the specified
	 * RequestType, targeting the specified URL with the specified
	 * authentication token.
	 * 
	 * @param type
	 *            type of GDataRequest
	 * @param requestUrl
	 *            request target URL
	 * @param authToken
	 *            token authenticating request to server
	 * @param headerMap
	 *            map containing additional headers to set
	 * @param privateHeaderMap
	 *            map containing additional headers to set that should not be
	 *            logged (eg. authentication info)
	 * @throws IOException
	 *             on error initializing service connection
	 */
	protected GoogleGDataRequest(RequestType type, URL requestUrl,
			ContentType contentType, HttpAuthToken authToken,
			Map<String, String> headerMap,
			Map<String, String> privateHeaderMap,
			URLFetchService urlFetchService) throws IOException {

		super(type, requestUrl, contentType, authToken, headerMap,
				privateHeaderMap, urlFetchService);
	}

	/**
	 * The GoogleService instance that constructed the request.
	 */
	private GoogleService service;

	/**
	 * Returns the {@link Version} that will be used to execute the request on
	 * the target service or {@code null} if the service is not versioned.
	 * 
	 * @return version sent with the request or {@code null}.
	 */
	public Version getRequestVersion() {
		// Always get the request version from the associated service, never
		// from
		// the version registry. There are aspects of request handling that
		// happen
		// outside the scope of Service.begin/endVersionScope.
		return service.getProtocolVersion();
	}

	/**
	 * The version associated with the response.
	 */
	private Version responseVersion;

	/**
	 * Returns the {@link Version} that was used by the target service to
	 * execute the request or {@code null} if the service is not versioned.
	 * 
	 * @return version returned with the response or {@code null}.
	 */
	public Version getResponseVersion() {
		if (!executed) {
			throw new IllegalStateException("Request has not been executed");
		}
		return responseVersion;
	}

	/**
	 * Sets the GoogleService associated with the request.
	 */
	public void setService(GoogleService service) {
		this.service = service;

		// This undocumented system property can be used to disable version
		// headers.
		// It exists only to support some unit test scenarios for
		// query-parameter
		// version configuration and back-compat defaulting when no version
		// information is sent by the client library.
		if (Boolean.getBoolean("GoogleGDataRequest.disableVersionHeader")) {
			return;
		}

		// Look up the active version for the type of service initiating the
		// request, and set the version header if found.
		try {
			Version requestVersion = service.getProtocolVersion();
			if (requestVersion != null) {
				setHeader(GDataProtocol.Header.VERSION, requestVersion
						.getVersionString());
			}
		} catch (IllegalStateException iae) {
			// Service may not be versioned.
		}
	}

	@Override
	public void execute() throws IOException, ServiceException {

		// Set the current active service, so cookie handling will be enabled.
		try {
			activeService.set(service);
			// Propagate redirects to our layer to add URL specific data to the
			// request (like URL dependant authentication headers)
			// httpConn.setInstanceFollowRedirects(false);
			super.execute();

			// Capture the version used to process the request
			String versionHeader = getResponseHeader(GDataProtocol.Header.VERSION);
			if (versionHeader != null) {
				GoogleService service = activeService.get();
				if (service != null) {
					responseVersion = new Version(service.getClass(),
							versionHeader);
				}

			}
		} finally {
			activeService.set(null);
		}
	}

	@Override
	protected void handleErrorResponse() throws IOException, ServiceException {

		try {
			switch (httpResp.getResponseCode()) {
			case HttpURLConnection.HTTP_MOVED_PERM:
			case HttpURLConnection.HTTP_MOVED_TEMP:
				throw new RedirectRequiredException(
						HttpURLConnection.HTTP_MOVED_TEMP,
						getResponseHeader("Location"));
			}
			super.handleErrorResponse();
		} catch (AuthenticationException e) {
			// Throw a more specific exception for session expiration.
			String msg = e.getMessage();
			if (msg != null && msg.contains("Token expired")) {
				SessionExpiredException se = new SessionExpiredException(e
						.getMessage());
				se.setResponse(e.getResponseContentType(), e.getResponseBody());
				throw se;
			}
			throw e;
		}
	}
}
